Padroneggia la validazione delle React Server Action. Un'immersione profonda nell'elaborazione dei form, nelle migliori pratiche di sicurezza e nelle tecniche avanzate utilizzando Zod, useFormState e useFormStatus.
Validazione delle React Server Action: Una Guida Completa all'Elaborazione e alla Sicurezza degli Input dei Form
L'introduzione delle React Server Actions ha segnato un cambiamento di paradigma significativo nello sviluppo full-stack con framework come Next.js. Permettendo ai componenti client di invocare direttamente le funzioni lato server, possiamo ora creare applicazioni più coese, efficienti e interattive con meno boilerplate. Tuttavia, questa potente nuova astrazione porta in primo piano una responsabilità critica: la robusta validazione e sicurezza degli input.
Quando il confine tra client e server diventa così uniforme, è facile trascurare i principi fondamentali della sicurezza web. Qualsiasi input proveniente da un utente non è affidabile e deve essere rigorosamente verificato sul server. Questa guida fornisce un'esplorazione completa dell'elaborazione e della validazione degli input dei form all'interno delle React Server Actions, coprendo tutto, dai principi di base ai modelli avanzati, pronti per la produzione, che garantiscono che la tua applicazione sia sia user-friendly che sicura.
Cosa sono esattamente le React Server Actions?
Prima di approfondire la validazione, riepiloghiamo brevemente cosa sono le Server Actions. In sostanza, sono funzioni che definisci sul server ma che puoi eseguire dal client. Quando un utente invia un form o fa clic su un pulsante, una Server Action può essere chiamata direttamente, bypassando la necessità di creare manualmente endpoint API, gestire le richieste `fetch` e gestire gli stati di caricamento/errore.
Sono costruite sulla base dei form HTML e dell'API `FormData` della Piattaforma Web, rendendole progressivamente migliorate per impostazione predefinita. Ciò significa che i tuoi form funzioneranno anche se JavaScript non riesce a caricarsi, fornendo un'esperienza utente resiliente.
Esempio di una Server Action di base:
// app/actions.js
'use server';
export async function createUser(formData) {
const name = formData.get('name');
const email = formData.get('email');
// ... logica per salvare l'utente nel database
console.log('Creazione utente:', { name, email });
}
// app/page.js
import { createUser } from './actions';
export default function UserForm() {
return (
);
}
Questa semplicità è potente, ma nasconde anche la complessità di ciò che sta accadendo. La funzione `createUser` viene eseguita esclusivamente sul server, ma viene invocata da un componente client. Questa linea diretta alla tua logica del server è precisamente il motivo per cui la validazione non è solo una funzionalità, ma un requisito.
L'importanza costante della validazione
Nel mondo delle Server Actions, ogni funzione è una porta aperta al tuo server. Una corretta validazione agisce come la guardia a quella porta. Ecco perché non è negoziabile:
- Integrità dei dati: Il tuo database e lo stato dell'applicazione dipendono da dati puliti e prevedibili. La validazione assicura che non memorizzi indirizzi email malformati, stringhe vuote dove dovrebbero esserci nomi o testo in un campo destinato ai numeri.
- Migliore esperienza utente (UX): Gli utenti commettono errori. Messaggi di errore chiari, immediati e specifici per il contesto li guidano a correggere il loro input, riducendo la frustrazione e migliorando i tassi di completamento dei form.
- Sicurezza assoluta: Questo è l'aspetto più critico. Senza la validazione lato server, la tua applicazione è vulnerabile a una serie di attacchi, tra cui:
- SQL Injection: Un attore malintenzionato potrebbe inviare comandi SQL in un campo del form per manipolare il tuo database.
- Cross-Site Scripting (XSS): Se memorizzi e renderizzi input utente non sanificati, un attaccante potrebbe iniettare script dannosi che vengono eseguiti nei browser di altri utenti.
- Denial of Service (DoS): L'invio di dati inaspettatamente grandi o computazionalmente costosi potrebbe sopraffare le risorse del tuo server.
Validazione lato client contro validazione lato server: un partenariato necessario
È importante capire che la validazione dovrebbe avvenire in due posti:
- Validazione lato client: Questo è per l'UX. Fornisce un feedback immediato senza un round-trip di rete. Puoi utilizzare semplici attributi HTML5 come `required`, `minLength`, `pattern` o JavaScript per controllare i formati mentre l'utente digita. Tuttavia, può essere facilmente bypassato disabilitando JavaScript o utilizzando gli strumenti per sviluppatori.
- Validazione lato server: Questo è per la sicurezza e l'integrità dei dati. È la fonte finale di verità della tua applicazione. Indipendentemente da ciò che accade sul client, il server deve ri-validare tutto ciò che riceve. Le Server Actions sono il posto perfetto per implementare questa logica.
Regola empirica: Utilizza la validazione lato client per una migliore esperienza utente, ma fidati sempre solo della validazione lato server per la sicurezza.
Implementazione della validazione nelle Server Actions: da base ad avanzata
Costruiamo la nostra strategia di validazione, iniziando con un approccio semplice e passando a una soluzione più robusta e scalabile utilizzando strumenti moderni.
Approccio 1: Validazione manuale e restituzione dello stato
Il modo più semplice per gestire la validazione è aggiungere istruzioni `if` all'interno della tua Server Action e restituire un oggetto che indica il successo o l'insuccesso.
// app/actions.js
'use server';
import { redirect } from 'next/navigation';
export async function createInvoice(formData) {
const customerName = formData.get('customerName');
const amount = formData.get('amount');
if (!customerName || customerName.trim() === '') {
return { success: false, message: 'Il nome del cliente è obbligatorio.' };
}
if (!amount || isNaN(Number(amount)) || Number(amount) <= 0) {
return { success: false, message: 'Inserisci un importo valido maggiore di zero.' };
}
// ... logica per creare la fattura nel database
console.log('Fattura creata per', customerName, 'con importo', amount);
redirect('/dashboard/invoices');
}
Questo approccio funziona, ma ha un importante difetto di UX: richiede un ricaricamento completo della pagina per visualizzare il messaggio di errore. Non possiamo facilmente mostrare il messaggio nella pagina del form stesso. È qui che entrano in gioco gli hook di React per le Server Actions.
Approccio 2: Utilizzo di `useFormState` per una gestione degli errori senza interruzioni
L'hook `useFormState` è progettato specificamente per questo scopo. Consente a una Server Action di restituire lo stato che può essere utilizzato per aggiornare l'UI senza un evento di navigazione completo. È la pietra angolare della moderna gestione dei form con le Server Actions.
Rifattorizziamo il nostro form di creazione della fattura.
Passaggio 1: Aggiorna la Server Action
L'azione ora deve accettare due argomenti: `prevState` e `formData`. Dovrebbe restituire un nuovo oggetto di stato che `useFormState` utilizzerà per aggiornare il componente.
// app/actions.js
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
// Definisci la forma dello stato iniziale
const initialState = {
message: null,
errors: {},
};
export async function createInvoice(prevState, formData) {
const customerName = formData.get('customerName');
const amount = formData.get('amount');
const status = formData.get('status');
const errors = {};
if (!customerName || customerName.trim().length < 2) {
errors.customerName = 'Il nome del cliente deve contenere almeno 2 caratteri.';
}
if (!amount || isNaN(Number(amount)) || Number(amount) <= 0) {
errors.amount = 'Inserisci un importo valido.';
}
if (status !== 'pending' && status !== 'paid') {
errors.status = 'Seleziona uno stato valido.';
}
if (Object.keys(errors).length > 0) {
return {
message: 'Impossibile creare la fattura. Controlla i campi.',
errors,
};
}
try {
// ... logica per salvare nel database
console.log('Fattura creata con successo!');
} catch (e) {
return {
message: 'Errore del database: impossibile creare la fattura.',
errors: {},
};
}
// Rivalida la cache per la pagina delle fatture e reindirizza
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
Passaggio 2: Aggiorna il componente Form con `useFormState`
Nel nostro componente client, utilizzeremo l'hook per gestire lo stato del form e visualizzare gli errori.
// app/ui/invoices/create-form.js
'use client';
import { useFormState } from 'react-dom';
import { createInvoice } from '@/app/actions';
const initialState = {
message: null,
errors: {},
};
export function CreateInvoiceForm() {
const [state, dispatch] = useFormState(createInvoice, initialState);
return (
);
}
Ora, quando l'utente invia un form non valido, la Server Action viene eseguita, restituisce l'oggetto di errore e `useFormState` aggiorna la variabile `state`. Il componente viene ri-renderizzato, visualizzando i messaggi di errore specifici proprio accanto ai campi corrispondenti, il tutto senza un ricaricamento della pagina. Questo è un enorme miglioramento dell'UX!
Approccio 3: Migliorare l'UX con `useFormStatus`
Cosa succede mentre la Server Action è in esecuzione? L'utente potrebbe fare clic sul pulsante di invio più volte. Possiamo fornire un feedback utilizzando l'hook `useFormStatus`, che ci fornisce informazioni sullo stato dell'ultimo invio del form.
Importante: `useFormStatus` deve essere utilizzato in un componente che è un elemento figlio dell'elemento `<form>`.
// app/ui/submit-button.js
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton() {
const { pending } = useFormStatus();
return (
);
}
// Nel tuo componente CreateInvoiceForm:
import { SubmitButton } from './submit-button';
// ...
Con questa semplice aggiunta, il pulsante di invio sarà ora disabilitato e mostrerà "Creazione..." mentre il server sta elaborando la richiesta, impedendo invii duplicati e fornendo un feedback chiaro all'utente.
Approccio 4: Validazione di livello di produzione con Zod
I controlli `if` manuali vanno bene per form semplici, ma per applicazioni complesse diventano verbose, soggetti a errori e difficili da mantenere. È qui che librerie di validazione dello schema come Zod, Yup o Valibot brillano.
Zod è una libreria di dichiarazione e validazione dello schema, prima di TypeScript. Ti consente di definire un singolo schema per i tuoi dati che può essere utilizzato sia sul server che sul client, garantendo la coerenza e la sicurezza dei tipi.
Passaggio 1: Definisci uno schema Zod
Definiamo uno schema per il nostro form di fattura. Questo diventa l'unica fonte di verità per ciò che costituisce una fattura valida.
// app/lib/schemas.js
import { z } from 'zod';
export const InvoiceSchema = z.object({
id: z.string(), // Lo avremo per gli aggiornamenti, ma non per la creazione
customerId: z.string({ invalid_type_error: 'Seleziona un cliente.' }),
amount: z.coerce
.number()
.gt(0, { message: 'Inserisci un importo superiore a $0.' }),
status: z.enum(['pending', 'paid'], {
invalid_type_error: 'Seleziona uno stato della fattura.',
}),
date: z.string(),
});
export const CreateInvoice = InvoiceSchema.omit({ id: true, date: true });
Passaggio 2: Utilizza lo schema nella tua Server Action
Ora, possiamo sostituire tutti i nostri controlli manuali con una singola chiamata al nostro schema Zod. Il metodo `safeParse` di Zod restituirà i dati convalidati o un oggetto di errore dettagliato.
// app/actions.js
'use server';
import { z } from 'zod';
import { CreateInvoice } from '@/app/lib/schemas';
export async function createInvoiceWithZod(prevState, formData) {
// 1. Valida i campi del form usando Zod
const validatedFields = CreateInvoice.safeParse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
// 2. Se la validazione fallisce, restituisci subito gli errori.
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Campi mancanti. Impossibile creare la fattura.',
};
}
// 3. Prepara i dati per l'inserimento nel database
const { customerId, amount, status } = validatedFields.data;
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
// 4. Inserisci i dati nel database
try {
// await sql`...` la tua query del database qui
console.log('Fattura creata con successo con dati convalidati.');
} catch (error) {
return {
message: 'Errore del database: impossibile creare la fattura.',
};
}
// 5. Rivalida e reindirizza
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
Nota quanto è più pulito questo. La logica di validazione è dichiarativa e co-localizzata nel nostro file di schema. La responsabilità dell'azione è ora quella di orchestrare la validazione, l'elaborazione dei dati e l'interazione con il database. Il metodo `flatten().fieldErrors` di Zod fornisce un oggetto perfettamente strutturato che mappa i nomi dei campi a un array di messaggi di errore, che è esattamente ciò di cui il nostro componente form ha bisogno.
Migliori pratiche di sicurezza critiche per le Server Actions
L'utilizzo di una libreria di validazione è un enorme passo avanti, ma la vera sicurezza richiede un approccio multilivello. Ogni Server Action è un potenziale vettore di attacco.
1. Supponi sempre un intento dannoso
Non fidarti mai, mai, dell'input dell'utente. Questa è la regola d'oro. Anche se il tuo codice lato client invia dati perfetti, un attaccante può utilizzare strumenti come `curl` o Postman per inviare una richiesta creata manualmente direttamente al tuo endpoint Server Action. La tua logica lato server è la tua unica difesa affidabile.
- Valida tutto: Valida non solo il formato ma anche la logica di business. A questo utente è consentito creare una fattura per *questo* cliente? L'importo è entro un intervallo ragionevole?
- Sanifica per l'output: Mentre React esegue automaticamente l'escape dei dati per prevenire XSS durante il rendering, se stai utilizzando dati in altri contesti (ad esempio, generando e-mail, registrando), fai attenzione a sanificarli per prevenire l'iniezione di script in tali sistemi.
2. Autenticazione e autorizzazione sono obbligatorie
Ogni Server Action che esegue una mutazione (crea, aggiorna, elimina) o accede a dati protetti deve verificare l'identità e le autorizzazioni dell'utente.
Inizia sempre la tua azione con un controllo della sessione:
// app/actions.js
'use server';
import { auth } from '@/auth'; // Supponendo che tu utilizzi NextAuth.js o simile
import { sql } from '@vercel/postgres';
export async function deleteInvoice(id) {
const session = await auth();
if (!session?.user) {
// O genera un errore
return { message: 'Autenticazione richiesta.' };
}
// Controllo dell'autorizzazione: questo utente ha il permesso di eliminare?
// Ciò potrebbe comportare il controllo dei ruoli o della proprietà.
const userRole = session.user.role;
if (userRole !== 'admin') {
return { message: 'Azione non autorizzata.' };
}
try {
await sql`DELETE FROM invoices WHERE id = ${id}`;
revalidatePath('/dashboard/invoices');
return { message: 'Fattura eliminata.' };
} catch (error) {
return { message: 'Errore del database: impossibile eliminare la fattura.' };
}
}
3. Protezione contro CSRF (Cross-Site Request Forgery)
Gli attacchi CSRF ingannano un utente connesso facendogli inviare inconsapevolmente una richiesta dannosa alla tua applicazione. Fortunatamente, framework come Next.js hanno una protezione CSRF integrata per le Server Actions utilizzando una combinazione di tecniche, tra cui i cookie a doppio invio e i controlli di origine. Sebbene ciò venga gestito per te, è fondamentale esserne a conoscenza e assicurarti di non creare inavvertitamente vulnerabilità configurando in modo errato la tua configurazione.
4. Implementa il Rate Limiting
Un utente o bot malintenzionato potrebbe inviare spamming agli invii del tuo form, tentando di forzare l'accesso in modo brutale, esaurire le connessioni al tuo database o riempire il tuo sistema di dati spazzatura. L'implementazione del rate limiting sulle Server Actions critiche è essenziale.
Puoi utilizzare servizi come Upstash Rate Limiting o implementare la tua logica utilizzando un archivio chiave-valore come Redis. La chiave è in genere l'indirizzo IP o l'ID utente.
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
// Crea un nuovo limitatore di frequenza, consentendo 5 richieste ogni 10 secondi
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '10 s'),
});
export async function sensitiveAction(formData) {
const ip = headers().get('x-forwarded-for'); // Oppure ottieni l'ID utente dalla sessione
const { success } = await ratelimit.limit(ip);
if (!success) {
return { message: 'Troppe richieste. Riprova più tardi.' };
}
// ... il resto della logica dell'azione
}
Considerazioni globali e di accessibilità
Costruire per un pubblico globale richiede di pensare oltre la semplice implementazione tecnica.
Internazionalizzazione (i18n) dei messaggi di errore
L'hardcoding dei messaggi di errore in inglese non è l'ideale per un'applicazione globale. Un approccio migliore è quello di far restituire alla tua Server Action *codici* o *chiavi* di errore.
// Nell'azione
if (!validatedFields.success) {
// ...
return { errors: { customerId: ['error.customer.required'] } };
}
// Nel componente client, utilizzando una libreria come 'react-i18next'
import { useTranslation } from 'react-i18next';
function MyForm() {
const { t } = useTranslation();
const [state, dispatch] = useFormState(...);
// ...
{state.errors?.customerId &&
{t(state.errors.customerId[0])}
}
}
Questo separa la logica di validazione dalla presentazione e consente al tuo front-end di gestire le traduzioni in modo trasparente.
Accessibilità (a11y)
Quando si visualizzano gli errori, assicurati che siano accessibili agli utenti delle tecnologie assistive. Associa i messaggi di errore ai relativi input del form utilizzando l'attributo `aria-describedby`.
<div>
<label htmlFor="customerName">Nome cliente</label>
<input
id="customerName"
name="customerName"
type="text"
aria-describedby="customer-error"
/>
<div id="customer-error" aria-live="polite" role="alert">
{state.errors?.customerName &&
state.errors.customerName.map((error) => (
<p key={error}>{error}</p>
))}
</div>
</div>
L'attributo `aria-live="polite"` garantirà che gli screen reader annuncino il messaggio di errore quando appare.
Conclusione: Una nuova era di responsabilità
Le React Server Actions sono uno strumento potente che semplifica lo sviluppo full-stack, avvicinando la logica del server all'UI come mai prima d'ora. Questo potere, tuttavia, richiede una mentalità disciplinata e incentrata sulla sicurezza.
Sfruttando hook come `useFormState` e `useFormStatus`, possiamo creare form progressivamente migliorati con un'esperienza utente eccellente. Integrando robuste librerie di validazione dello schema come Zod, possiamo scrivere una logica di validazione pulita, manutenibile e type-safe. E aderendo ai principi fondamentali di sicurezza—autenticazione, autorizzazione, rate limiting e validazione sempre sul server—possiamo creare applicazioni non solo eleganti ed efficienti, ma anche resilienti e sicure.
Tratta ogni Server Action come un endpoint API pubblico. Convalida i suoi input, controlla le sue autorizzazioni e gestisci i suoi errori con grazia. In questo modo, puoi sfruttare con sicurezza l'intero potenziale di questo nuovo ed entusiasmante capitolo nello sviluppo di React.